[Frontend] Add offline prefix-cache workload analyzer CLI#48369
[Frontend] Add offline prefix-cache workload analyzer CLI#48369harsh543 wants to merge 3 commits into
Conversation
Adds `vllm analyze-prefix-cache`: a no-GPU CLI that tokenizes a plain- prompt JSONL request dataset, computes the same full-block hash-chain vLLM's V1 scheduler uses for automatic prefix caching, and reports an offline cacheability estimate (shared-prefix groups, reusable full-block tokens, cacheability ratio) so RAG/agent/multi-turn workloads can be analyzed for cache-friendliness before spending GPU time on a serving run. v1 scope, per the RFC discussion in vllm-project#47993: plain-prompt JSONL only (no OpenAI chat/batch format yet), no LoRA/multimodal/cache-salt extra-key support, text and JSON output. Reuses hash_block_tokens/get_hash_fn_by_name/ init_none_hash from vllm/v1/core/kv_cache_utils.py directly rather than adding a new public helper, per the RFC's open design question on API layering. Reusable-token accounting is computed by counting reuse once per distinct BlockHash value across all request chains (not by summing per-reported- group prefix lengths), since a block hash already uniquely identifies one trie node across every chain that reaches it -- summing over top-k reported groups instead would double-count blocks shared by an overlapping subset of requests at a different depth. Covered by a dedicated regression test. Verified end-to-end on Linux x86_64 (this repo's own machine is macOS/ arm64, which vLLM doesn't ship precompiled wheels for): installed cleanly, and the real pytest run caught one genuine test bug of its own -- test_load_plain_prompt_jsonl asserted the wrong default id for a record following a blank line. load_plain_prompt_jsonl's default id is the 0-indexed physical line number (per its own docstring), which correctly includes skipped blank lines rather than silently renumbering around them; the test's expected value was wrong, not the implementation. Fixed the assertion. Full suite is green: 5 passed -> 6 passed after the fix (see PR description for the run). Fixes vllm-project#47993 Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: harsh543 <harsh.rocks@hotmail.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging. To run CI, PR reviewers can either: Add If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de251ae8c3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@hmellor @mgoin @DarkLight1337 @russellb @njhill — could one of you please review and apply |
Addresses a P2 finding from the automated Codex review on this PR (prefix_cache_analysis.py:221): chains is keyed by record.request_id, so a JSONL file with two records sharing the same id silently overwrote all but the last chain. total_prompt_tokens and total_full_block_tokens were already incremented for both records before the collision, so num_requests, prefix grouping, and reusable-token accounting then quietly operated on fewer records than were actually tokenized -- and two genuinely duplicate prompts sharing an id would never be counted as reusable against each other, since only one chain survived in the dict. Fail loud instead: raise ValueError the moment a duplicate id would collide, before it silently overwrites. Matches the existing fail-fast style already used for the missing 'prompt' field case in load_plain_prompt_jsonl. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: harsh543 <harsh.rocks@hotmail.com>
raravind007
left a comment
There was a problem hiding this comment.
Read through the full diff and verified the core claims against the actual vllm/v1/core/kv_cache_utils.py source rather than taking the PR description at face value.
Confirmed correct:
_full_block_hash_chaincallshash_block_tokens(hash_fn, parent, block_tokens)withparent=Nonefor a request's first block, then threadsparent = block_hashforward for subsequent blocks. I checked this against the real production path (get_request_block_hasher's inner closure): it also passes bareNonefor a request's first block, and theNONE_HASHsubstitution happens insidehash_block_tokensitself (if not parent_block_hash: parent_block_hash = NONE_HASH). So this isn't just internally consistent — it reproduces the production hash-chain construction exactly.init_none_hash(hash_fn)is called before any hashing, which is required sinceNONE_HASHis a lazily-initialized module global.- Partial trailing blocks are correctly dropped via
len(token_ids) // block_size, matching the engine's full-block-only caching behavior. - The double-counting fix described in the PR body is real, not just asserted:
_reusable_tokens_from_chainsdedupes by distinctBlockHashvalue (correct, since parent-chaining means a given hash value already uniquely identifies one trie node), andtest_reusable_tokens_does_not_double_count_overlapping_groupsencodes the exacta/b/cscenario from the writeup and asserts the corrected total (112) — the "verified by hand-walking the trie" claim is backed by an actual regression test, not just prose.
One finding not covered in the PR description:
The shared-prefix grouping in analyze() materializes tuple(chain[:depth]) for every depth in 1..len(chain) for every request:
for request_id, chain in chains.items():
for depth in range(1, len(chain) + 1):
prefix = tuple(chain[:depth])
groups_by_prefix[prefix].append(request_id)Each chain[:depth] slice + tuple construction + dict hash/compare is O(depth), so this is O(N × L²) across N requests with average chain length L. This lands hardest on exactly the workload this tool is meant to highlight: a long shared system prompt or tool schema (e.g. 8,192 tokens → L≈512 blocks at block_size=16) reused across thousands of requests. At that shape it's roughly N × L² ≈ 10,000 × 512² ≈ 2.6B tuple-element operations for the grouping step alone.
This doesn't affect correctness — _reusable_tokens_from_chains is already O(N × L) and unaffected — it's isolated to the display-grouping path. But it's worth addressing before this sees larger inputs, since a CPU-side analysis step that gets slow on the flagship "long shared prefix across many requests" case undercuts the "cheap to run before burning GPU time" pitch. A trie keyed on (parent_node, block_hash), built once in a single O(total blocks) pass, would avoid the repeated slicing and give the same grouping result.
Not blocking on my end, but wanted to flag it now rather than after this scales past demo-sized inputs. Nice catch on the double-counting bug, and the regression test for it is exactly the kind of thing that should be in here.
…eview Addresses a scaling finding from @raravind007's review on this PR: groups_by_prefix built a tuple(chain[:depth]) key for every depth of every request's chain, which is O(chain_length^2) per request. That lands hardest on exactly the workload this tool is meant to highlight -- a long shared system prompt or tool schema reused across many requests (their example: an 8k-token shared prefix at block_size=16 is ~512 blocks; at 10k requests that's ~2.6B tuple-element operations for grouping alone). Replaced with a trie built in one O(total blocks) pass (each chain step is a single dict lookup/insert), where a shared prefix is identified by shared trie nodes instead of by re-slicing and re-hashing a growing tuple at every depth. A winning group's actual block-hash tuple is only reconstructed (via parent pointers) for the at-most-top_k_groups nodes that survive selection, not for every node in the trie. Verified equivalence against the old implementation with a 300-trial fuzz test comparing both algorithms' output on random synthetic chain trees (not included in this commit -- exploratory verification only); 6 of 300 trials initially disagreed, all on groups tied exactly on (depth, count) competing for the last top_k slot. Neither the old nor new code had a defined tiebreak for that case -- it fell out of incidental dict/stack iteration order in both -- so this also adds an explicit deterministic tiebreak (smallest request_id in the group) instead of leaving output order-dependent. With that tiebreak applied to both, all 300 trials matched exactly. Measured ~8.7x speedup at a modest N=2000/L=200 shape; the gap widens quadratically with chain length, so it's much larger at the review's N=10000/L=512 example. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: harsh543 <harsh.rocks@hotmail.com>
|
@raravind007 Thanks for the thorough review — cross-checking against the real Fixed the scaling issue in Verified equivalence against the old implementation with a 300-trial fuzz test on random synthetic chain trees before pushing. First pass, 6/300 trials disagreed — but only ever on groups exactly tied on Measured ~8.7x speedup at a modest N=2000/L=200 shape — the gap widens quadratically with chain length, so it's substantially larger at your N=10000/L=512 example. Regression tests for the grouping correctness and the tiebreak determinism are in the same commit. |
|
Status update: both review items are addressed (duplicate- @mgoin — |
Purpose
Adds
vllm analyze-prefix-cache: an offline, no-GPU CLI that estimates how prefix-cache-friendly a request dataset is, before spending GPU time on a serving run.Fixes #47993.
Motivation: vLLM's automatic prefix caching reuses KV-cache blocks only when the full-block hash chain matches exactly. Small prompt-formatting differences, per-request metadata, or chat-template variance can silently prevent reuse. Today there's no way to answer "is my RAG/agent/multi-turn dataset cache-friendly?" without actually running inference. This CLI answers that statically: tokenize the dataset, compute the same full-block hash chain the V1 scheduler uses, and report cacheability.
v1 scope (per the design discussion in #47993, and my scoping comment there): plain-prompt JSONL only — OpenAI chat/batch JSONL is a fast-follow once this report shape is agreed on. No LoRA/multimodal/cache-salt extra-key support yet. Reuses
hash_block_tokens/get_hash_fn_by_name/init_none_hashfromvllm/v1/core/kv_cache_utils.pydirectly rather than adding a new public helper — the RFC flagged this as an open layering question; happy to help design a stable public helper afterward once there's a second caller to generalize from.Design note: a real bug I found and fixed while building this
The most important number this tool reports —
estimated_reusable_full_block_tokens(and the cacheability ratio derived from it) — was double-counting in my first implementation. Writing this up because it's the one non-obvious design decision in the diff, and I want it visible for review rather than buried.The bug: requests are grouped by their longest shared hash-chain prefix, and I originally summed
len(prefix) * block_size * (num_requests - 1)across the top-K reported groups. That's wrong whenever two distinct-membership groups overlap in blocks — which happens whenever a subset of requests shares a longer prefix than the full set does. Example:{a,b}share a 5-block prefix;{a,b,c}share a shorter 2-block prefix within that same lineage. Both are real, legitimately-reported groups. Summing them independently double-counts blocks 0-1, which belong to both:The fix: block hashes are already chained on their parent (
hash_block_tokens(hash_fn, parent, tokens)), so a givenBlockHashvalue uniquely identifies one trie node across every chain that reaches it — regardless of which top-K group it happened to land in. Counting reuse once per distinctBlockHashvalue, not once per reported group, gives the correct non-overlapping total:Extracted into
_reusable_tokens_from_chains()so it's unit-testable independent of the tokenizer (see Test Plan). I verified the fix against two hand-computed cases (a 3-way/2-way overlap, and a 3-way-then-2-way case) with a standalone script before writing it into the module — both matched the manual trie walk exactly (112 and 80 respectively).The
top_prefix_groupsdisplay logic is unaffected by this — it's fine for both{a,b}@depth-5 and{a,b,c}@depth-2 to be reported as separate groups in the human-readable output, since they're genuinely different sharing clusters. Only the aggregate reusable-token total needed to stop double-counting the overlap.Changes
vllm/entrypoints/prefix_cache_analysis.py(new): JSONL parsing, full-block hash-chain construction (mirrorshash_block_tokensblock-by-block, dropping partial trailing blocks), prefix grouping for display, and the corrected_reusable_tokens_from_chainsaccounting.vllm/entrypoints/cli/analyze_prefix_cache.py(new):AnalyzePrefixCacheSubcommand, following the sameCLISubcommandpattern ascollect_env.py—--model,--input,--block-size,--hash-algo,--output-format {text,json},--top-k-groups,--trust-remote-code.vllm/entrypoints/cli/main.py: registers the new subcommand (2-line addition, same pattern as every other CLI subcommand).tests/entrypoints/unit_tests/test_prefix_cache_analysis.py(new): JSONL parsing (happy path + missing-field error), end-to-end grouping/divergence via the real tokenizer (facebook/opt-125m), JSON round-trip, zero-full-blocks edge case, and a dedicated regression test for the double-counting bug above using syntheticBlockHashvalues (no tokenizer dependency, so it can't be broken by tokenizer/BPE changes).Non-goals for v1
Matches the RFC: no runtime hit-rate prediction, no scheduler/eviction/preemption modeling, no natural-language miss classification, no LoRA/multimodal/cache-salt inference, no engine behavior changes. This is a static, offline upper-bound estimate — the text output says so explicitly ("not a hit-rate guarantee").
Test Plan
Test Result
ruff checkandruff format --check: clean.Every import (
vllm.tokenizers.get_tokenizer,vllm.utils.hashing.get_hash_fn_by_name,vllm.v1.core.kv_cache_utils.{BlockHash, hash_block_tokens, init_none_hash}) checked against currentmainsignatures directly, not from memory.The reusable-token fix verified independently with a standalone script against two hand-computed synthetic cases before being written into the module (see Design note above) — both matched a manual trie walk exactly.
pytestactually run, on a throwaway Linux x86_64 CPU box (this repo's dev machine is macOS/arm64, which vLLM doesn't ship precompiled wheels for —VLLM_USE_PRECOMPILED=1 uv pip install -e .fails there with "No precompiled vllm wheel found for architecture arm64"). Freshgit cloneof this branch, freshuv venv,VLLM_USE_PRECOMPILED=1 uv pip install -e . --torch-backend=auto, then:(
--noconftestbecause vLLM's sharedtests/conftest.pypulls intbliband other heavy test-suite-wide fixtures this self-contained test doesn't need — installing the fullrequirements/test/*.inset felt disproportionate for one file with no shared fixtures.)First run of this actually caught a real bug — in the test, not the implementation:
test_load_plain_prompt_jsonlasserted the wrong defaultidfor a record following a blank line (expected"1", got"2").load_plain_prompt_jsonl's own docstring says the default id is the 0-indexed physical line number, which correctly counts the skipped blank line — so"2"was right and the test's expectation was wrong. Fixed the assertion, not the code. Second run: 6/6 green.AI assistance disclosure
Claude Code assisted with implementation, caught the double-counting bug described above during review of my own first draft, and caught the test-assertion bug during the actual Linux test run; commits are marked
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>. I reviewed and understand every line, including the trie-accounting argument above, and can defend it in review.